mirror of
				https://github.com/neovim/neovim.git
				synced 2025-10-26 12:27:24 +00:00 
			
		
		
		
	feat(lsp): support annotated text edits (#34508)
This commit is contained in:
		 Maria José Solano
					Maria José Solano
				
			
				
					committed by
					
						 GitHub
						GitHub
					
				
			
			
				
	
			
			
			 GitHub
						GitHub
					
				
			
						parent
						
							a5c55d200b
						
					
				
				
					commit
					835f11595f
				
			| @@ -2334,7 +2334,8 @@ Lua module: vim.lsp.util                                            *lsp-util* | |||||||
|  |  | ||||||
|  |  | ||||||
|                                      *vim.lsp.util.apply_text_document_edit()* |                                      *vim.lsp.util.apply_text_document_edit()* | ||||||
| apply_text_document_edit({text_document_edit}, {index}, {position_encoding}) | apply_text_document_edit({text_document_edit}, {index}, {position_encoding}, | ||||||
|  |                          {change_annotations}) | ||||||
|     Applies a `TextDocumentEdit`, which is a list of changes to a single |     Applies a `TextDocumentEdit`, which is a list of changes to a single | ||||||
|     document. |     document. | ||||||
|  |  | ||||||
| @@ -2343,18 +2344,21 @@ apply_text_document_edit({text_document_edit}, {index}, {position_encoding}) | |||||||
|       • {index}               (`integer?`) Optional index of the edit, if from |       • {index}               (`integer?`) Optional index of the edit, if from | ||||||
|                               a list of edits (or nil, if not from a list) |                               a list of edits (or nil, if not from a list) | ||||||
|       • {position_encoding}   (`'utf-8'|'utf-16'|'utf-32'?`) |       • {position_encoding}   (`'utf-8'|'utf-16'|'utf-32'?`) | ||||||
|  |       • {change_annotations}  (`table<string, lsp.ChangeAnnotation>?`) | ||||||
|  |  | ||||||
|     See also: ~ |     See also: ~ | ||||||
|       • https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentEdit |       • https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentEdit | ||||||
|  |  | ||||||
|                                              *vim.lsp.util.apply_text_edits()* |                                              *vim.lsp.util.apply_text_edits()* | ||||||
| apply_text_edits({text_edits}, {bufnr}, {position_encoding}) | apply_text_edits({text_edits}, {bufnr}, {position_encoding}, | ||||||
|  |                  {change_annotations}) | ||||||
|     Applies a list of text edits to a buffer. |     Applies a list of text edits to a buffer. | ||||||
|  |  | ||||||
|     Parameters: ~ |     Parameters: ~ | ||||||
|       • {text_edits}         (`lsp.TextEdit[]`) |       • {text_edits}          (`(lsp.TextEdit|lsp.AnnotatedTextEdit)[]`) | ||||||
|       • {bufnr}               (`integer`) Buffer id |       • {bufnr}               (`integer`) Buffer id | ||||||
|       • {position_encoding}   (`'utf-8'|'utf-16'|'utf-32'`) |       • {position_encoding}   (`'utf-8'|'utf-16'|'utf-32'`) | ||||||
|  |       • {change_annotations}  (`table<string, lsp.ChangeAnnotation>?`) | ||||||
|  |  | ||||||
|     See also: ~ |     See also: ~ | ||||||
|       • https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textEdit |       • https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textEdit | ||||||
|   | |||||||
| @@ -182,6 +182,7 @@ LSP | |||||||
| • Support for the `disabled` field on code actions. | • Support for the `disabled` field on code actions. | ||||||
| • The function form of `cmd` in a vim.lsp.Config or vim.lsp.ClientConfig | • The function form of `cmd` in a vim.lsp.Config or vim.lsp.ClientConfig | ||||||
|   receives the resolved config as the second arg: `cmd(dispatchers, config)`. |   receives the resolved config as the second arg: `cmd(dispatchers, config)`. | ||||||
|  | • Support for annotated text edits. | ||||||
|  |  | ||||||
| LUA | LUA | ||||||
|  |  | ||||||
|   | |||||||
| @@ -431,6 +431,7 @@ function protocol.make_client_capabilities() | |||||||
|           properties = { 'edit', 'command' }, |           properties = { 'edit', 'command' }, | ||||||
|         }, |         }, | ||||||
|         disabledSupport = true, |         disabledSupport = true, | ||||||
|  |         honorsChangeAnnotations = true, | ||||||
|       }, |       }, | ||||||
|       codeLens = { |       codeLens = { | ||||||
|         dynamicRegistration = false, |         dynamicRegistration = false, | ||||||
| @@ -529,6 +530,7 @@ function protocol.make_client_capabilities() | |||||||
|       rename = { |       rename = { | ||||||
|         dynamicRegistration = true, |         dynamicRegistration = true, | ||||||
|         prepareSupport = true, |         prepareSupport = true, | ||||||
|  |         honorsChangeAnnotations = true, | ||||||
|       }, |       }, | ||||||
|       publishDiagnostics = { |       publishDiagnostics = { | ||||||
|         tagSupport = { |         tagSupport = { | ||||||
| @@ -562,6 +564,7 @@ function protocol.make_client_capabilities() | |||||||
|       workspaceEdit = { |       workspaceEdit = { | ||||||
|         resourceOperations = { 'rename', 'create', 'delete' }, |         resourceOperations = { 'rename', 'create', 'delete' }, | ||||||
|         normalizesLineEndings = true, |         normalizesLineEndings = true, | ||||||
|  |         changeAnnotationSupport = { groupsOnLabel = true }, | ||||||
|       }, |       }, | ||||||
|       semanticTokens = { |       semanticTokens = { | ||||||
|         refreshSupport = true, |         refreshSupport = true, | ||||||
|   | |||||||
| @@ -287,14 +287,16 @@ local function get_line_byte_from_position(bufnr, position, position_encoding) | |||||||
| end | end | ||||||
|  |  | ||||||
| --- Applies a list of text edits to a buffer. | --- Applies a list of text edits to a buffer. | ||||||
| ---@param text_edits lsp.TextEdit[] | ---@param text_edits (lsp.TextEdit|lsp.AnnotatedTextEdit)[] | ||||||
| ---@param bufnr integer Buffer id | ---@param bufnr integer Buffer id | ||||||
| ---@param position_encoding 'utf-8'|'utf-16'|'utf-32' | ---@param position_encoding 'utf-8'|'utf-16'|'utf-32' | ||||||
|  | ---@param change_annotations? table<string, lsp.ChangeAnnotation> | ||||||
| ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textEdit | ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textEdit | ||||||
| function M.apply_text_edits(text_edits, bufnr, position_encoding) | function M.apply_text_edits(text_edits, bufnr, position_encoding, change_annotations) | ||||||
|   validate('text_edits', text_edits, 'table', false) |   validate('text_edits', text_edits, 'table', false) | ||||||
|   validate('bufnr', bufnr, 'number', false) |   validate('bufnr', bufnr, 'number', false) | ||||||
|   validate('position_encoding', position_encoding, 'string', false) |   validate('position_encoding', position_encoding, 'string', false) | ||||||
|  |   validate('change_annotations', change_annotations, 'table', true) | ||||||
|  |  | ||||||
|   if not next(text_edits) then |   if not next(text_edits) then | ||||||
|     return |     return | ||||||
| @@ -307,6 +309,10 @@ function M.apply_text_edits(text_edits, bufnr, position_encoding) | |||||||
|   end |   end | ||||||
|   vim.bo[bufnr].buflisted = true |   vim.bo[bufnr].buflisted = true | ||||||
|  |  | ||||||
|  |   local marks = {} --- @type table<string,[integer,integer]> | ||||||
|  |   local has_eol_text_edit = false | ||||||
|  |  | ||||||
|  |   local function apply_text_edits() | ||||||
|     -- Fix reversed range and indexing each text_edits |     -- Fix reversed range and indexing each text_edits | ||||||
|     for index, text_edit in ipairs(text_edits) do |     for index, text_edit in ipairs(text_edits) do | ||||||
|       --- @cast text_edit lsp.TextEdit|{_index: integer} |       --- @cast text_edit lsp.TextEdit|{_index: integer} | ||||||
| @@ -323,11 +329,11 @@ function M.apply_text_edits(text_edits, bufnr, position_encoding) | |||||||
|       end |       end | ||||||
|     end |     end | ||||||
|  |  | ||||||
|   --- @cast text_edits  (lsp.TextEdit|{_index: integer})[] |     --- @cast text_edits (lsp.TextEdit|lsp.AnnotatedTextEdit|{_index: integer})[] | ||||||
|  |  | ||||||
|     -- Sort text_edits |     -- Sort text_edits | ||||||
|   ---@param a lsp.TextEdit | { _index: integer } |     ---@param a (lsp.TextEdit|lsp.AnnotatedTextEdit|{_index: integer}) | ||||||
|   ---@param b lsp.TextEdit | { _index: integer } |     ---@param b (lsp.TextEdit|lsp.AnnotatedTextEdit|{_index: integer}) | ||||||
|     ---@return boolean |     ---@return boolean | ||||||
|     table.sort(text_edits, function(a, b) |     table.sort(text_edits, function(a, b) | ||||||
|       if a.range.start.line ~= b.range.start.line then |       if a.range.start.line ~= b.range.start.line then | ||||||
| @@ -340,15 +346,12 @@ function M.apply_text_edits(text_edits, bufnr, position_encoding) | |||||||
|     end) |     end) | ||||||
|  |  | ||||||
|     -- save and restore local marks since they get deleted by nvim_buf_set_lines |     -- save and restore local marks since they get deleted by nvim_buf_set_lines | ||||||
|   local marks = {} --- @type table<string,[integer,integer]> |  | ||||||
|     for _, m in pairs(vim.fn.getmarklist(bufnr)) do |     for _, m in pairs(vim.fn.getmarklist(bufnr)) do | ||||||
|       if m.mark:match("^'[a-z]$") then |       if m.mark:match("^'[a-z]$") then | ||||||
|         marks[m.mark:sub(2, 2)] = { m.pos[2], m.pos[3] - 1 } -- api-indexed |         marks[m.mark:sub(2, 2)] = { m.pos[2], m.pos[3] - 1 } -- api-indexed | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|  |  | ||||||
|   -- Apply text edits. |  | ||||||
|   local has_eol_text_edit = false |  | ||||||
|     for _, text_edit in ipairs(text_edits) do |     for _, text_edit in ipairs(text_edits) do | ||||||
|       -- Normalize line ending |       -- Normalize line ending | ||||||
|       text_edit.newText, _ = string.gsub(text_edit.newText, '\r\n?', '\n') |       text_edit.newText, _ = string.gsub(text_edit.newText, '\r\n?', '\n') | ||||||
| @@ -395,6 +398,66 @@ function M.apply_text_edits(text_edits, bufnr, position_encoding) | |||||||
|         api.nvim_buf_set_text(bufnr, start_row, start_col, end_row, end_col, text) |         api.nvim_buf_set_text(bufnr, start_row, start_col, end_row, end_col, text) | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   --- Track how many times each change annotation is applied to build up the final description. | ||||||
|  |   ---@type table<string, integer> | ||||||
|  |   local change_count = {} | ||||||
|  |  | ||||||
|  |   -- If there are any annotated text edits, we need to confirm them before applying the edits. | ||||||
|  |   local confirmations = {} ---@type table<string, integer> | ||||||
|  |   for _, text_edit in ipairs(text_edits) do | ||||||
|  |     if text_edit.annotationId then | ||||||
|  |       assert( | ||||||
|  |         change_annotations ~= nil, | ||||||
|  |         'change_annotations must be provided for annotated text edits' | ||||||
|  |       ) | ||||||
|  |  | ||||||
|  |       local annotation = assert( | ||||||
|  |         change_annotations[text_edit.annotationId], | ||||||
|  |         string.format('No change annotation found for ID: %s', text_edit.annotationId) | ||||||
|  |       ) | ||||||
|  |  | ||||||
|  |       if annotation.needsConfirmation then | ||||||
|  |         confirmations[text_edit.annotationId] = (confirmations[text_edit.annotationId] or 0) + 1 | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       change_count[text_edit.annotationId] = (change_count[text_edit.annotationId] or 0) + 1 | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   if next(confirmations) then | ||||||
|  |     local message = { 'Apply all changes?' } | ||||||
|  |     for id, count in pairs(confirmations) do | ||||||
|  |       local annotation = assert(change_annotations)[id] | ||||||
|  |       message[#message + 1] = annotation.label | ||||||
|  |         .. (annotation.description and (string.format(': %s', annotation.description)) or '') | ||||||
|  |         .. (count > 1 and string.format(' (%d)', count) or '') | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     local response = vim.fn.confirm(table.concat(message, '\n'), '&Yes\n&No', 1, 'Question') | ||||||
|  |     if response == 1 then | ||||||
|  |       -- Proceed with applying text edits. | ||||||
|  |       apply_text_edits() | ||||||
|  |     else | ||||||
|  |       -- Don't apply any text edits. | ||||||
|  |       return | ||||||
|  |     end | ||||||
|  |   else | ||||||
|  |     -- No confirmations needed, apply text edits directly. | ||||||
|  |     apply_text_edits() | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   if change_annotations ~= nil and next(change_count) then | ||||||
|  |     local change_message = { 'Applied changes:' } | ||||||
|  |     for id, count in pairs(change_count) do | ||||||
|  |       local annotation = change_annotations[id] | ||||||
|  |       change_message[#change_message + 1] = annotation.label | ||||||
|  |         .. (annotation.description and (': ' .. annotation.description) or '') | ||||||
|  |         .. (count > 1 and string.format(' (%d)', count) or '') | ||||||
|  |     end | ||||||
|  |     vim.notify(table.concat(change_message, '\n'), vim.log.levels.INFO) | ||||||
|  |   end | ||||||
|  |  | ||||||
|   local max = api.nvim_buf_line_count(bufnr) |   local max = api.nvim_buf_line_count(bufnr) | ||||||
|  |  | ||||||
| @@ -427,8 +490,14 @@ end | |||||||
| ---@param text_document_edit lsp.TextDocumentEdit | ---@param text_document_edit lsp.TextDocumentEdit | ||||||
| ---@param index? integer: Optional index of the edit, if from a list of edits (or nil, if not from a list) | ---@param index? integer: Optional index of the edit, if from a list of edits (or nil, if not from a list) | ||||||
| ---@param position_encoding? 'utf-8'|'utf-16'|'utf-32' | ---@param position_encoding? 'utf-8'|'utf-16'|'utf-32' | ||||||
|  | ---@param change_annotations? table<string, lsp.ChangeAnnotation> | ||||||
| ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentEdit | ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentEdit | ||||||
| function M.apply_text_document_edit(text_document_edit, index, position_encoding) | function M.apply_text_document_edit( | ||||||
|  |   text_document_edit, | ||||||
|  |   index, | ||||||
|  |   position_encoding, | ||||||
|  |   change_annotations | ||||||
|  | ) | ||||||
|   local text_document = text_document_edit.textDocument |   local text_document = text_document_edit.textDocument | ||||||
|   local bufnr = vim.uri_to_bufnr(text_document.uri) |   local bufnr = vim.uri_to_bufnr(text_document.uri) | ||||||
|   if position_encoding == nil then |   if position_encoding == nil then | ||||||
| @@ -455,7 +524,7 @@ function M.apply_text_document_edit(text_document_edit, index, position_encoding | |||||||
|     return |     return | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   M.apply_text_edits(text_document_edit.edits, bufnr, position_encoding) |   M.apply_text_edits(text_document_edit.edits, bufnr, position_encoding, change_annotations) | ||||||
| end | end | ||||||
|  |  | ||||||
| local function path_components(path) | local function path_components(path) | ||||||
| @@ -637,7 +706,7 @@ function M.apply_workspace_edit(workspace_edit, position_encoding) | |||||||
|       elseif change.kind then --- @diagnostic disable-line:undefined-field |       elseif change.kind then --- @diagnostic disable-line:undefined-field | ||||||
|         error(string.format('Unsupported change: %q', vim.inspect(change))) |         error(string.format('Unsupported change: %q', vim.inspect(change))) | ||||||
|       else |       else | ||||||
|         M.apply_text_document_edit(change, idx, position_encoding) |         M.apply_text_document_edit(change, idx, position_encoding, workspace_edit.changeAnnotations) | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|     return |     return | ||||||
| @@ -650,7 +719,7 @@ function M.apply_workspace_edit(workspace_edit, position_encoding) | |||||||
|  |  | ||||||
|   for uri, changes in pairs(all_changes) do |   for uri, changes in pairs(all_changes) do | ||||||
|     local bufnr = vim.uri_to_bufnr(uri) |     local bufnr = vim.uri_to_bufnr(uri) | ||||||
|     M.apply_text_edits(changes, bufnr, position_encoding) |     M.apply_text_edits(changes, bufnr, position_encoding, workspace_edit.changeAnnotations) | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2069,13 +2069,16 @@ describe('LSP', function() | |||||||
|   end) |   end) | ||||||
|  |  | ||||||
|   describe('apply_text_edits', function() |   describe('apply_text_edits', function() | ||||||
|  |     local buffer_text = { | ||||||
|  |       'First line of text', | ||||||
|  |       'Second line of text', | ||||||
|  |       'Third line of text', | ||||||
|  |       'Fourth line of text', | ||||||
|  |       'å å ɧ 汉语 ↥ 🤦 🦄', | ||||||
|  |     } | ||||||
|  |  | ||||||
|     before_each(function() |     before_each(function() | ||||||
|       insert(dedent([[ |       insert(dedent(table.concat(buffer_text, '\n'))) | ||||||
|         First line of text |  | ||||||
|         Second line of text |  | ||||||
|         Third line of text |  | ||||||
|         Fourth line of text |  | ||||||
|         å å ɧ 汉语 ↥ 🤦 🦄]])) |  | ||||||
|     end) |     end) | ||||||
|  |  | ||||||
|     it('applies simple edits', function() |     it('applies simple edits', function() | ||||||
| @@ -2226,6 +2229,34 @@ describe('LSP', function() | |||||||
|       eq({ 2, 1 }, api.nvim_buf_get_mark(1, 'a')) |       eq({ 2, 1 }, api.nvim_buf_get_mark(1, 'a')) | ||||||
|     end) |     end) | ||||||
|  |  | ||||||
|  |     it('applies edit based on confirmation response', function() | ||||||
|  |       --- @type lsp.AnnotatedTextEdit | ||||||
|  |       local edit = make_edit(0, 0, 5, 0, 'foo') | ||||||
|  |       edit.annotationId = 'annotation-id' | ||||||
|  |  | ||||||
|  |       local function test(response) | ||||||
|  |         exec_lua(function() | ||||||
|  |           ---@diagnostic disable-next-line: duplicate-set-field | ||||||
|  |           vim.fn.confirm = function() | ||||||
|  |             return response | ||||||
|  |           end | ||||||
|  |  | ||||||
|  |           vim.lsp.util.apply_text_edits( | ||||||
|  |             { edit }, | ||||||
|  |             1, | ||||||
|  |             'utf-16', | ||||||
|  |             { ['annotation-id'] = { label = 'Insert "foo"', needsConfirmation = true } } | ||||||
|  |           ) | ||||||
|  |         end, { response }) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       test(2) -- 2 = No | ||||||
|  |       eq(buffer_text, buf_lines(1)) | ||||||
|  |  | ||||||
|  |       test(1) -- 1 = Yes | ||||||
|  |       eq({ 'foo' }, buf_lines(1)) | ||||||
|  |     end) | ||||||
|  |  | ||||||
|     describe('cursor position', function() |     describe('cursor position', function() | ||||||
|       it("don't fix the cursor if the range contains the cursor", function() |       it("don't fix the cursor if the range contains the cursor", function() | ||||||
|         api.nvim_win_set_cursor(0, { 2, 6 }) |         api.nvim_win_set_cursor(0, { 2, 6 }) | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user