From 0eda9c616cf1d36ae5e67bbbbf7f51f56426c4c5 Mon Sep 17 00:00:00 2001 From: Philippe Massicotte Date: Mon, 6 Apr 2026 07:30:44 -0400 Subject: [PATCH 1/2] feat(R.nvim): add lsp rename provider Add support for renaming variables and functions across R projects. Update documentation and configuration to reflect the new feature. Enhance LSP functionality with a rename method for symbol references. --- doc/R.nvim.txt | 5 + lua/r/config.lua | 4 + lua/r/lsp/highlight.lua | 8 +- lua/r/lsp/init.lua | 19 +++ lua/r/lsp/references.lua | 151 +++++++++++----------- lua/r/lsp/rename.lua | 49 +++++++ lua/r/lsp/utils.lua | 11 ++ lua/r/server.lua | 1 + rnvimserver/rnvimserver.c | 102 +++++++++++++++ tests/test_rename_spec.lua | 256 +++++++++++++++++++++++++++++++++++++ 10 files changed, 529 insertions(+), 77 deletions(-) create mode 100644 lua/r/lsp/rename.lua create mode 100644 tests/test_rename_spec.lua diff --git a/doc/R.nvim.txt b/doc/R.nvim.txt index e1d8fb69..640146b2 100644 --- a/doc/R.nvim.txt +++ b/doc/R.nvim.txt @@ -1549,6 +1549,7 @@ features in your R.nvim config. Below are the default values: document_highlight = true, -- enable the document highlight provider document_symbol = true, -- enable the document symbol provider workspace_symbol = true, -- enable the workspace symbol provider + rename = true, -- enable the rename provider doc_width = 0, fun_data_1 = { "select", "rename", "mutate", "filter" }, fun_data_2 = { ggplot = { "aes" }, with = { "*" } }, @@ -1566,6 +1567,7 @@ Meaning of options: - `signature`: Enable the signature help provider - `implementation`: Enable the implementation provider + - `definition`: Enable the definition provider - `use_git_files`: Use `git ls-files` to find R files when indexing the @@ -1582,6 +1584,9 @@ Meaning of options: - `workspace_symbol`: Enable the workspace symbol provider + - `rename`: Enable the rename provider. This allows you to rename variables and + functions across your R project (see |R.nvim-rename|). + Note: The definition and references providers also recognize symbols defined in `{targets}` pipelines. Target-defining calls (e.g., `tar_target()`, `tar_file()`) where the first argument is an unquoted identifier are indexed diff --git a/lua/r/config.lua b/lua/r/config.lua index 2109e098..2e95b1a2 100644 --- a/lua/r/config.lua +++ b/lua/r/config.lua @@ -34,6 +34,9 @@ local hooks = require("r.hooks") ---Enable the document highlight provider ---@field document_highlight? boolean --- +---Enable the rename provider +---@field rename? boolean +--- ---Text width of documentation window displayed when ---an item is selected ---@field doc_width? integer @@ -508,6 +511,7 @@ local config = { document_symbol = true, workspace_symbol = true, document_highlight = true, + rename = true, doc_width = 0, fun_data_1 = { "select", "rename", "mutate", "filter" }, fun_data_2 = { ggplot = { "aes" }, with = { "*" } }, diff --git a/lua/r/lsp/highlight.lua b/lua/r/lsp/highlight.lua index 87296ed5..5536a22b 100644 --- a/lua/r/lsp/highlight.lua +++ b/lua/r/lsp/highlight.lua @@ -8,7 +8,8 @@ local scope = require("r.lsp.scope") -- R grammar: all assignments are binary_operator with named fields lhs/rhs -- Walk up to the nearest assignment ancestor so that compound targets like -- x[1] <- 1, x$y <- 1, or names(x) <- ... are correctly classified as writes. -local assign_target = { ["<-"] = "lhs", ["<<-"] = "lhs", ["="] = "lhs", ["->"] = "rhs", ["->>"] = "rhs" } +local assign_target = + { ["<-"] = "lhs", ["<<-"] = "lhs", ["="] = "lhs", ["->"] = "rhs", ["->>"] = "rhs" } local function highlight_kind(node, bufnr) local cur = node while true do @@ -50,7 +51,10 @@ function M.document_highlight(req_id, line, col, bufnr) local highlights = {} for _, node in query:iter_captures(root, bufnr) do - if vim.treesitter.get_node_text(node, bufnr) == word then + if + not utils.is_argument_name_node(node) + and vim.treesitter.get_node_text(node, bufnr) == word + then local sr, sc = node:start() local _, ec = node:end_() diff --git a/lua/r/lsp/init.lua b/lua/r/lsp/init.lua index 9b2a539e..337bb8b7 100644 --- a/lua/r/lsp/init.lua +++ b/lua/r/lsp/init.lua @@ -705,6 +705,25 @@ M.references = function(req_id, line, col, bufnr) require("r.lsp.references").find_references(req_id, line, col, bufnr) end +---Rename all references to the symbol under cursor +---@param req_id string +---@param line integer LSP line (0-indexed) +---@param col integer LSP character (0-indexed) +---@param bufnr integer Source buffer (resolved from textDocument URI) +---@param new_name string Replacement identifier +M.rename = function(req_id, line, col, bufnr, new_name) + bufnr = get_r_bufnr(bufnr) + if vim.bo[bufnr].filetype ~= "r" then + local lang = "other" + vim.api.nvim_buf_call(bufnr, function() lang = get_lang(line) end) + if lang ~= "r" then + M.send_msg({ code = "N" .. req_id }) + return + end + end + require("r.lsp.rename").rename_symbol(req_id, line, col, bufnr, new_name) +end + ---Find implementations of the symbol under cursor ---@param req_id string ---@param line integer LSP line (0-indexed) diff --git a/lua/r/lsp/references.lua b/lua/r/lsp/references.lua index d257fb87..7d369b4d 100644 --- a/lua/r/lsp/references.lua +++ b/lua/r/lsp/references.lua @@ -7,14 +7,13 @@ local workspace = require("r.lsp.workspace") local utils = require("r.lsp.utils") local scope = require("r.lsp.scope") ---- Find all workspace references without scope filtering (fallback) +--- Collect all workspace references without scope filtering (fallback) ---@param symbol string Symbol name ----@param req_id string LSP request ID ---@param bufnr integer Source buffer number -local function find_all_workspace_references(symbol, req_id, bufnr) +---@return table[] locations List of {file, line, col, end_col} +local function collect_workspace_references(symbol, bufnr) local ast = require("r.lsp.ast") - -- Prepare workspace utils.prepare_workspace() local all_refs = {} @@ -37,59 +36,50 @@ local function find_all_workspace_references(symbol, req_id, bufnr) local file = vim.api.nvim_buf_get_name(bufnr) for _, node in query:iter_captures(root, bufnr) do - local text = vim.treesitter.get_node_text(node, bufnr) - if text == symbol then - local start_row, start_col = node:start() - local _, end_col = node:end_() - table.insert(all_refs, { - file = file, - line = start_row, - col = start_col, - end_col = end_col, - }) + if not utils.is_argument_name_node(node) then + local text = vim.treesitter.get_node_text(node, bufnr) + if text == symbol then + local start_row, start_col = node:start() + local _, end_col = node:end_() + table.insert(all_refs, { + file = file, + line = start_row, + col = start_col, + end_col = end_col, + }) + end end end end end - all_refs = utils.deduplicate_locations(all_refs) - - if #all_refs > 0 then - utils.send_response("R", req_id, { locations = all_refs }) - else - utils.send_null(req_id) - end + return utils.deduplicate_locations(all_refs) end ---- Find all references to a symbol across workspace (scope-aware) ----@param req_id string LSP request ID +--- Find all locations for a symbol across workspace (scope-aware). +--- Returns the location list and the word, so callers (references, rename) +--- can reuse the result without duplicating the search logic. ---@param line integer 0-indexed row from LSP params ---@param col integer 0-indexed column from LSP params ---@param bufnr integer Source buffer number -function M.find_references(req_id, line, col, bufnr) +---@return {locations: table[], word: string}|nil +function M.find_locations(line, col, bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() local row = line - -- Get keyword from LSP position params local word, err = utils.get_word_at_bufpos(bufnr, row, col) - if err or not word then - utils.send_null(req_id) - return - end + if err or not word then return nil end local current_scope = scope.get_scope_at_position(bufnr, row, col) if not current_scope then - -- No scope found, fallback to workspace search - find_all_workspace_references(word, req_id, bufnr) - return + local locs = collect_workspace_references(word, bufnr) + return { locations = locs, word = word, resolved = false } end local target_definition = scope.resolve_symbol(word, current_scope) if not target_definition then - -- Symbol not resolved in scope, fallback to workspace search - -- This handles cases like add(2,3) where add is defined in other files - find_all_workspace_references(word, req_id, bufnr) - return + local locs = collect_workspace_references(word, bufnr) + return { locations = locs, word = word, resolved = false } end utils.prepare_workspace() @@ -97,10 +87,7 @@ function M.find_references(req_id, line, col, bufnr) local all_refs = {} local query = require("r.lsp.queries").get("references") - if not query then - utils.send_null(req_id) - return - end + if not query then return { locations = {}, word = word } end local ast = require("r.lsp.ast") local parser, root = ast.get_parser_and_root(bufnr) @@ -108,24 +95,27 @@ function M.find_references(req_id, line, col, bufnr) if parser and root then for _, node in query:iter_captures(root, bufnr) do - local text = vim.treesitter.get_node_text(node, bufnr) - if text == word then - local start_row, start_col = node:start() - local _, end_col = node:end_() - - local usage_scope = - scope.get_scope_at_position(bufnr, start_row, start_col) - if usage_scope then - local resolved = scope.resolve_symbol(word, usage_scope) - if - resolved and utils.is_same_definition(resolved, target_definition) - then - table.insert(all_refs, { - file = file, - line = start_row, - col = start_col, - end_col = end_col, - }) + if not utils.is_argument_name_node(node) then + local text = vim.treesitter.get_node_text(node, bufnr) + if text == word then + local start_row, start_col = node:start() + local _, end_col = node:end_() + + local usage_scope = + scope.get_scope_at_position(bufnr, start_row, start_col) + if usage_scope then + local resolved = scope.resolve_symbol(word, usage_scope) + if + resolved + and utils.is_same_definition(resolved, target_definition) + then + table.insert(all_refs, { + file = file, + line = start_row, + col = start_col, + end_col = end_col, + }) + end end end end @@ -133,7 +123,6 @@ function M.find_references(req_id, line, col, bufnr) end -- For public (file-level) symbols, also search workspace files for usages - -- Cross-file references can only see public symbols if target_definition.visibility == "public" then local workspace_locations = workspace.get_definitions(word) local buffer = require("r.lsp.buffer") @@ -173,19 +162,22 @@ function M.find_references(req_id, line, col, bufnr) if temp_root then for _, node in query:iter_captures(temp_root, temp_bufnr) do - local text = vim.treesitter.get_node_text(node, temp_bufnr) - if text == word then - local in_function = - ast.find_ancestor(node, "function_definition") - if not in_function then - local start_row, start_col = node:start() - local _, end_col = node:end_() - table.insert(all_refs, { - file = filepath, - line = start_row, - col = start_col, - end_col = end_col, - }) + if not utils.is_argument_name_node(node) then + local text = + vim.treesitter.get_node_text(node, temp_bufnr) + if text == word then + local in_function = + ast.find_ancestor(node, "function_definition") + if not in_function then + local start_row, start_col = node:start() + local _, end_col = node:end_() + table.insert(all_refs, { + file = filepath, + line = start_row, + col = start_col, + end_col = end_col, + }) + end end end end @@ -198,12 +190,21 @@ function M.find_references(req_id, line, col, bufnr) end all_refs = utils.deduplicate_locations(all_refs) + return { locations = all_refs, word = word, resolved = true } +end - if #all_refs > 0 then - utils.send_response("R", req_id, { locations = all_refs }) - else +--- Find all references to a symbol across workspace (scope-aware) +---@param req_id string LSP request ID +---@param line integer 0-indexed row from LSP params +---@param col integer 0-indexed column from LSP params +---@param bufnr integer Source buffer number +function M.find_references(req_id, line, col, bufnr) + local result = M.find_locations(line, col, bufnr) + if not result or #result.locations == 0 then utils.send_null(req_id) + return end + utils.send_response("R", req_id, { locations = result.locations }) end return M diff --git a/lua/r/lsp/rename.lua b/lua/r/lsp/rename.lua new file mode 100644 index 00000000..ed8a9a9d --- /dev/null +++ b/lua/r/lsp/rename.lua @@ -0,0 +1,49 @@ +--- LSP rename for R.nvim +--- Provides textDocument/rename functionality + +local M = {} + +local utils = require("r.lsp.utils") + +--- Rename all references to the symbol at the given position +---@param req_id string LSP request ID +---@param line integer 0-indexed row from LSP params +---@param col integer 0-indexed column from LSP params +---@param bufnr integer Source buffer number +---@param new_name string The replacement identifier +function M.rename_symbol(req_id, line, col, bufnr, new_name) + local result = require("r.lsp.references").find_locations(line, col, bufnr) + if not result or #result.locations == 0 then + utils.send_null(req_id) + return + end + + -- If the symbol wasn't resolved in the local scope and has no definition + -- anywhere in the project, it belongs to an external package. Renaming it + -- locally would silently produce broken code, so refuse. + if not result.resolved then + local workspace = require("r.lsp.workspace") + if #workspace.get_definitions(result.word) == 0 then + utils.send_null(req_id) + return + end + end + + -- Group edits by file URI into WorkspaceEdit.changes format + local changes = {} + for _, loc in ipairs(result.locations) do + local uri = "file://" .. loc.file + if not changes[uri] then changes[uri] = {} end + table.insert(changes[uri], { + range = { + start = { line = loc.line, character = loc.col }, + ["end"] = { line = loc.line, character = loc.end_col }, + }, + newText = new_name, + }) + end + + utils.send_response("X", req_id, { changes = changes }) +end + +return M diff --git a/lua/r/lsp/utils.lua b/lua/r/lsp/utils.lua index 132783d8..131e140b 100644 --- a/lua/r/lsp/utils.lua +++ b/lua/r/lsp/utils.lua @@ -458,6 +458,17 @@ function M.get_word_at_bufpos(bufnr, row, col) return nil, nil end +--- Returns true when node is a named argument key, e.g. `filename` in `f(filename = x)`. +--- Those identifiers are parameter names of the callee, not references to local symbols. +---@param node TSNode +---@return boolean +function M.is_argument_name_node(node) + local parent = node:parent() + if not parent or parent:type() ~= "argument" then return false end + local name_nodes = parent:field("name") + return #name_nodes > 0 and name_nodes[1]:id() == node:id() +end + --- Check whether two symbol definitions refer to the same declaration site. ---@param def1 SymbolDefinition ---@param def2 SymbolDefinition diff --git a/lua/r/server.lua b/lua/r/server.lua index e75feb63..39b23b2f 100644 --- a/lua/r/server.lua +++ b/lua/r/server.lua @@ -134,6 +134,7 @@ local start_rnvimserver = function() if not config.r_ls.workspace_symbol then table.insert(disable_parts, "workspaceSymbol") end + if not config.r_ls.rename then table.insert(disable_parts, "rename") end local disable = table.concat(disable_parts) rns_env.R_LS_DISABLE = disable diff --git a/rnvimserver/rnvimserver.c b/rnvimserver/rnvimserver.c index 94224759..438bf290 100644 --- a/rnvimserver/rnvimserver.c +++ b/rnvimserver/rnvimserver.c @@ -272,6 +272,12 @@ static void handle_initialize(const char *request_id) { p = str_cat(p, "\"workspaceSymbolProvider\":true"); has_cpblt = 1; } + if (!disable || strstr(disable, "rename") == NULL) { + if (has_cpblt) + p = str_cat(p, ","); + p = str_cat(p, "\"renameProvider\":true"); + has_cpblt = 1; + } str_cat(p, "}}}"); @@ -288,6 +294,7 @@ static void send_workspace_symbols_result(const char *params); static void send_references_result(const char *params); static void send_implementation_result(const char *params); static void send_document_highlight_result(const char *params); +static void send_rename_result(const char *params); static void handle_exe_cmd(const char *params) { Log("handle_exe_cmd: %s\n", params); @@ -341,6 +348,9 @@ static void handle_exe_cmd(const char *params) { case 'L': // Document highlight result from Lua send_document_highlight_result(params); break; + case 'X': // Rename result from Lua + send_rename_result(params); + break; case '1': // Start TCP server and wait nvimcom connection start_server(); break; @@ -519,6 +529,96 @@ static void handle_document_highlight(const char *id, const char *params) { handle_location_request(id, params, "document_highlight"); } +static void send_rename_result(const char *params) { + /* Extract orig_id without mutating params so the subsequent strstr for + * "changes" is not affected by cut_json_int's null-termination. */ + char id_str[16] = ""; + char *id_ptr = strstr(params, "\"orig_id\":"); + if (!id_ptr) + return; + snprintf(id_str, sizeof(id_str) - 1, "%ld", strtol(id_ptr + 10, NULL, 10)); + + char *changes_start = strstr(params, "\"changes\":{"); + if (!changes_start) { + send_null(id_str); + return; + } + changes_start += 10; /* points to '{' */ + + /* Walk forward counting braces to find the matching '}'. */ + int depth = 0; + char *p = changes_start; + while (*p) { + if (*p == '{') + depth++; + else if (*p == '}') { + if (--depth == 0) + break; + } + p++; + } + if (depth != 0 || !*p) { + send_null(id_str); + return; + } + + size_t changes_len = (size_t)(p - changes_start) + 1; + size_t result_size = changes_len + 256; + char *result = (char *)malloc(result_size); + snprintf(result, result_size, + "{\"jsonrpc\":\"2.0\",\"id\":%s,\"result\":{\"changes\":%.*s}}", + id_str, (int)changes_len, changes_start); + send_ls_response(id_str, result); + free(result); +} + +static void handle_rename(const char *id, const char *params) { + char uri[2048]; + extract_doc_uri(params, uri, sizeof(uri)); + + char *position = strstr(params, "\"position\":{"); + if (!position) + return; + position += 11; + char *line = strstr(position, "\"line\":"); + char *col = strstr(position, "\"character\":"); + + /* Extract newName BEFORE cut_json_int calls mutate the buffer */ + char new_name_raw[256] = ""; + char *nn = strstr(params, "\"newName\":\""); + if (nn) { + nn += 11; + char *nn_end = strchr(nn, '"'); + if (nn_end) { + size_t n = (size_t)(nn_end - nn); + if (n < sizeof(new_name_raw)) { + strncpy(new_name_raw, nn, n); + new_name_raw[n] = '\0'; + } + } + } + + /* Escape backslashes and single quotes for Lua single-quoted literal */ + char escaped[512] = ""; + size_t ei = 0; + for (size_t qi = 0; new_name_raw[qi] && ei + 2 < sizeof(escaped); qi++) { + if (new_name_raw[qi] == '\'' || new_name_raw[qi] == '\\') + escaped[ei++] = '\\'; + escaped[ei++] = new_name_raw[qi]; + } + escaped[ei] = '\0'; + + cut_json_int(&line, 7); + cut_json_int(&col, 12); + + char cmd[4096]; + snprintf( + cmd, sizeof(cmd) - 1, + "require('r.lsp').rename(%s, %s, %s, vim.uri_to_bufnr('%s'), '%s')", id, + line, col, uri, escaped); + send_cmd_to_nvim(cmd); +} + // Generic function to handle location-based LSP responses (definition, // references, implementation) static void send_location_result(const char *params) { @@ -838,6 +938,8 @@ static void lsp_loop(void) { handle_implementation(id, params); } else if (strcmp(method, "textDocument/documentHighlight") == 0) { handle_document_highlight(id, params); + } else if (strcmp(method, "textDocument/rename") == 0) { + handle_rename(id, params); } else if (strcmp(method, "initialize") == 0) { handle_initialize(id); } else if (strcmp(method, "initialized") == 0) { diff --git a/tests/test_rename_spec.lua b/tests/test_rename_spec.lua new file mode 100644 index 00000000..1976782d --- /dev/null +++ b/tests/test_rename_spec.lua @@ -0,0 +1,256 @@ +local assert = require("luassert") +local stub = require("luassert.stub") +local test_utils = require("./utils") + +describe("LSP textDocument/rename", function() + local rename_module + local lsp_module + local workspace_module + local utils_module + local sent_messages = {} + local send_msg_stub + local index_stub + local update_buf_stub + local get_definitions_stub + local find_r_files_stub + + -- Controls what workspace.get_definitions returns; reset in before_each. + -- Set this in individual tests before calling rename_symbol. + local fake_definitions = {} + + local function setup_test(content, cursor_pos) + return test_utils.setup_lsp_test(content, cursor_pos) + end + + local function get_last_message() return test_utils.get_last_message(sent_messages) end + + local function assert_null_response(req_id) + local msg = get_last_message() + assert.is_not_nil(msg, "No message was sent") + assert.equals("N" .. req_id, msg.code) + end + + local function assert_rename_response(req_id) + local msg = get_last_message() + assert.is_not_nil(msg, "No message was sent") + assert.equals("X", msg.code) + assert.equals(req_id, msg.orig_id) + assert.is_not_nil(msg.changes) + return msg + end + + local function count_edits(changes) + local n = 0 + for _, edits in pairs(changes) do + n = n + #edits + end + return n + end + + before_each(function() + sent_messages = {} + fake_definitions = {} + package.loaded["r.lsp.rename"] = nil + package.loaded["r.lsp.references"] = nil + package.loaded["r.lsp.workspace"] = nil + package.loaded["r.lsp.utils"] = nil + package.loaded["r.lsp"] = nil + lsp_module = require("r.lsp") + workspace_module = require("r.lsp.workspace") + utils_module = require("r.lsp.utils") + rename_module = require("r.lsp.rename") + send_msg_stub = stub( + lsp_module, + "send_msg", + function(params) table.insert(sent_messages, params) end + ) + -- Prevent actual workspace indexing + index_stub = stub(workspace_module, "index_workspace") + update_buf_stub = stub(workspace_module, "update_modified_buffer") + -- Default: no cross-file definitions; individual tests can set fake_definitions + get_definitions_stub = stub( + workspace_module, + "get_definitions", + function() return fake_definitions end + ) + -- Prevent cross-file searches from scanning the real filesystem + find_r_files_stub = stub(utils_module, "find_r_files") + end) + + after_each(function() + if send_msg_stub then send_msg_stub:revert() end + if index_stub then index_stub:revert() end + if update_buf_stub then update_buf_stub:revert() end + if get_definitions_stub then get_definitions_stub:revert() end + if find_r_files_stub then find_r_files_stub:revert() end + end) + + -- ------------------------------------------------------------------------- + + describe("basic rename", function() + it("returns null when cursor is not on an identifier", function() + local bufnr = setup_test("x <- 42", { 1, 5 }) + if not bufnr then return end + rename_module.rename_symbol("req1", 0, 5, bufnr, "y") + assert_null_response("req1") + end) + + it("renames definition and single usage", function() + -- row=0 col=0 edge case: R parser returns "program"; put definition on row=1 + local bufnr = setup_test("dummy <- 0\nx <- 1\nprint(x)", { 2, 0 }) + if not bufnr then return end + rename_module.rename_symbol("req2", 1, 0, bufnr, "y") + local msg = assert_rename_response("req2") + assert.equals(2, count_edits(msg.changes)) + for _, edits in pairs(msg.changes) do + for _, edit in ipairs(edits) do + assert.equals("y", edit.newText) + end + end + end) + + it("renames all occurrences", function() + local bufnr = setup_test("dummy <- 0\nx <- 1\nz <- x + x", { 2, 0 }) + if not bufnr then return end + rename_module.rename_symbol("req3", 1, 0, bufnr, "w") + local msg = assert_rename_response("req3") + assert.equals(3, count_edits(msg.changes)) + for _, edits in pairs(msg.changes) do + for _, edit in ipairs(edits) do + assert.equals("w", edit.newText) + end + end + end) + end) + + -- ------------------------------------------------------------------------- + + describe("named argument filtering", function() + it("skips named argument keys", function() + -- In ggsave(filename = filename), the first `filename` is an arg key + -- belonging to ggsave's signature so it must not be renamed. + -- Only the variable reference (the value) should be renamed. + local content = 'filename <- "out.pdf"\nggsave(filename = filename)' + local bufnr = setup_test(content, { 1, 0 }) + if not bufnr then return end + rename_module.rename_symbol("req4", 0, 0, bufnr, "path") + local msg = assert_rename_response("req4") + -- definition on line 0 + value reference on line 1 = 2; arg key excluded + assert.equals(2, count_edits(msg.changes)) + for _, edits in pairs(msg.changes) do + for _, edit in ipairs(edits) do + -- arg key `filename =` starts at col 7; must never appear in edits + if edit.range.start.line == 1 then + assert.not_equals(7, edit.range.start.character) + end + end + end + end) + end) + + -- ------------------------------------------------------------------------- + + describe("scope-aware rename", function() + it("renames only the shadowing inner variable", function() + local content = "x <- 100\nf <- function() {\n x <- 200\n print(x)\n}" + local bufnr = setup_test(content, { 3, 4 }) + if not bufnr then return end + -- cursor on inner `x <- 200` (row=2 0-indexed, col=4) + rename_module.rename_symbol("req5", 2, 4, bufnr, "inner_x") + local msg = assert_rename_response("req5") + for _, edits in pairs(msg.changes) do + for _, edit in ipairs(edits) do + assert.not_equals( + 0, + edit.range.start.line, + "outer x on line 0 must not be renamed" + ) + end + end + end) + + it("renames outer variable including references from inner scopes", function() + local content = "dummy <- 0\nx <- 100\nf <- function() {\n print(x)\n}" + local bufnr = setup_test(content, { 2, 0 }) + if not bufnr then return end + rename_module.rename_symbol("req6", 1, 0, bufnr, "y") + local msg = assert_rename_response("req6") + local has_outer = false + for _, edits in pairs(msg.changes) do + for _, edit in ipairs(edits) do + if edit.range.start.line == 1 then has_outer = true end + end + end + assert.is_true(has_outer, "outer definition on line 1 must be renamed") + end) + end) + + -- ------------------------------------------------------------------------- + + describe("external symbol guard", function() + it("refuses to rename a symbol with no project definition", function() + -- str_detect belongs to stringr; scope resolution fails and + -- workspace.get_definitions returns nothing → rename must refuse. + local bufnr = setup_test("str_detect(x, 'foo')", { 1, 0 }) + if not bufnr then return end + rename_module.rename_symbol("req7", 0, 0, bufnr, "my_detect") + assert_null_response("req7") + end) + + it("allows rename when workspace holds a definition for the symbol", function() + -- Simulate my_fn being defined in another project file + fake_definitions = { { file = "/project/utils.R", line = 0, col = 0 } } + local bufnr = setup_test("my_fn(x)", { 1, 1 }) + if not bufnr then return end + rename_module.rename_symbol("req8", 0, 1, bufnr, "new_fn") + assert_rename_response("req8") + end) + end) + + -- ------------------------------------------------------------------------- + + describe("changes format", function() + it("keys changes by file:// URI", function() + local bufnr = setup_test("dummy <- 0\nx <- 1\nprint(x)", { 2, 0 }) + if not bufnr then return end + rename_module.rename_symbol("req9", 1, 0, bufnr, "y") + local msg = assert_rename_response("req9") + for uri in pairs(msg.changes) do + assert.is_true( + vim.startswith(uri, "file://"), + "expected file:// URI, got: " .. uri + ) + end + end) + + it("edits have correct range structure and newText", function() + local bufnr = setup_test("dummy <- 0\nx <- 1\nprint(x)", { 2, 0 }) + if not bufnr then return end + rename_module.rename_symbol("req10", 1, 0, bufnr, "y") + local msg = assert_rename_response("req10") + for _, edits in pairs(msg.changes) do + for _, edit in ipairs(edits) do + assert.is_not_nil(edit.range) + assert.is_not_nil(edit.range.start) + assert.is_not_nil(edit.range["end"]) + assert.is_not_nil(edit.range.start.line) + assert.is_not_nil(edit.range.start.character) + assert.equals("y", edit.newText) + end + end + end) + + it("end character equals start character plus symbol length", function() + local bufnr = setup_test("dummy <- 0\nfoo <- 1\nprint(foo)", { 2, 0 }) + if not bufnr then return end + rename_module.rename_symbol("req11", 1, 0, bufnr, "bar") + local msg = assert_rename_response("req11") + for _, edits in pairs(msg.changes) do + for _, edit in ipairs(edits) do + local len = edit.range["end"].character - edit.range.start.character + assert.equals(3, len) -- len("foo") == 3 + end + end + end) + end) +end) From 2b50a02f70b70083e6653bd173532018ffc4205b Mon Sep 17 00:00:00 2001 From: Philippe Massicotte Date: Tue, 7 Apr 2026 08:30:09 -0400 Subject: [PATCH 2/2] refactor(lsp): rename is_same_definition to is_same_r_variable Refactor the function to better reflect its purpose of checking if two definitions refer to the same R variable, considering R's function-level scoping. Update references to this function across the codebase to ensure consistent behavior. test(rename): add test for renaming with reassignment in same scope Add a test case to verify that renaming a symbol within the same scope correctly updates all occurrences, including reassignments. This ensures that the refactored function handles R's scoping rules properly during rename operations. --- lua/r/lsp/highlight.lua | 2 +- lua/r/lsp/references.lua | 6 +++++- lua/r/lsp/utils.lua | 35 +++++++++++++++++++++++++++---- tests/test_rename_spec.lua | 42 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 6 deletions(-) diff --git a/lua/r/lsp/highlight.lua b/lua/r/lsp/highlight.lua index 5536a22b..12aaee6b 100644 --- a/lua/r/lsp/highlight.lua +++ b/lua/r/lsp/highlight.lua @@ -64,7 +64,7 @@ function M.document_highlight(req_id, line, col, bufnr) if usage_scope then local resolved = scope.resolve_symbol(word, usage_scope) include = resolved ~= nil - and utils.is_same_definition(resolved, target_definition) + and utils.is_same_r_variable(resolved, target_definition, bufnr) end else -- Symbol not resolved in scope: highlight all buffer occurrences diff --git a/lua/r/lsp/references.lua b/lua/r/lsp/references.lua index 7d369b4d..4b31c3b6 100644 --- a/lua/r/lsp/references.lua +++ b/lua/r/lsp/references.lua @@ -107,7 +107,11 @@ function M.find_locations(line, col, bufnr) local resolved = scope.resolve_symbol(word, usage_scope) if resolved - and utils.is_same_definition(resolved, target_definition) + and utils.is_same_r_variable( + resolved, + target_definition, + bufnr + ) then table.insert(all_refs, { file = file, diff --git a/lua/r/lsp/utils.lua b/lua/r/lsp/utils.lua index 131e140b..7b36af32 100644 --- a/lua/r/lsp/utils.lua +++ b/lua/r/lsp/utils.lua @@ -469,14 +469,41 @@ function M.is_argument_name_node(node) return #name_nodes > 0 and name_nodes[1]:id() == node:id() end ---- Check whether two symbol definitions refer to the same declaration site. +--- Check whether two definitions refer to the same R variable. +--- R has function-level scoping: all assignments to the same name within the +--- same function body (or both at file level) are the same variable, not +--- shadowing. This is needed for rename/references to capture reassignments. ---@param def1 SymbolDefinition ---@param def2 SymbolDefinition +---@param bufnr integer Buffer used for tree-sitter lookups ---@return boolean -function M.is_same_definition(def1, def2) - return M.normalize_path(def1.location.file) == M.normalize_path(def2.location.file) - and def1.location.line == def2.location.line +function M.is_same_r_variable(def1, def2, bufnr) + if M.normalize_path(def1.location.file) ~= M.normalize_path(def2.location.file) then + return false + end + -- Exact same position is trivially the same + if + def1.location.line == def2.location.line and def1.location.col == def2.location.col + then + return true + end + -- Both definitions must be in the same containing function (or both at + -- file level) to be considered the same variable. + local ast = require("r.lsp.ast") + local node1 = ast.node_at_position(bufnr, def1.location.line, def1.location.col) + local node2 = ast.node_at_position(bufnr, def2.location.line, def2.location.col) + if not node1 or not node2 then return false end + + local func1 = ast.find_ancestor(node1, "function_definition") + local func2 = ast.find_ancestor(node2, "function_definition") + + -- Both at file level (no containing function) + if not func1 and not func2 then return true end + -- Same containing function node + if func1 and func2 and func1:id() == func2:id() then return true end + + return false end --- Prepare workspace (consolidated pattern) diff --git a/tests/test_rename_spec.lua b/tests/test_rename_spec.lua index 1976782d..f10f7c7c 100644 --- a/tests/test_rename_spec.lua +++ b/tests/test_rename_spec.lua @@ -187,6 +187,48 @@ describe("LSP textDocument/rename", function() -- ------------------------------------------------------------------------- + describe("reassignment within same scope", function() + it("renames all occurrences including reassignments", function() + local content = table.concat({ + "dummy <- 0", + "f <- function(res, pkg, pkgname, funcmeth) {", + ' if (length(res) == 0 || (length(res) == 1 && res == "")) {', + ' res <- ""', + " } else {", + " if (is.null(pkg)) {", + " info <- pkgname", + " if (!is.na(funcmeth)) {", + ' if (info != "") {', + ' info <- paste0(info, ", ")', + " }", + ' info <- paste0(info, "function:", funcmeth, "()")', + " }", + " }", + " }", + "}", + }, "\n") + + -- cursor on first `info` (info <- pkgname), row=6 col=6 + local bufnr = setup_test(content, { 7, 6 }) + + if not bufnr then return end + + rename_module.rename_symbol("req_reassign", 6, 6, bufnr, "detail") + + local msg = assert_rename_response("req_reassign") + + assert.equals(6, count_edits(msg.changes)) + + for _, edits in pairs(msg.changes) do + for _, edit in ipairs(edits) do + assert.equals("detail", edit.newText) + end + end + end) + end) + + -- ------------------------------------------------------------------------- + describe("external symbol guard", function() it("refuses to rename a symbol with no project definition", function() -- str_detect belongs to stringr; scope resolution fails and